# -*- coding: utf-8 -*-
"""
A股/基金 历史行情下载与分析脚本（AKShare 增强版） v1.4.0
=====================================================

【功能概述】
- 支持标的：A股( stock )、ETF( etf )、LOF( lof )、开放式公募基金( openfund )
- 日/周/月 K 线（股票/ETF/LOF）：ak.stock_zh_a_hist / ak.fund_etf_hist_em / ak.fund_lof_hist_em
  - 支持复权：前复权(qfq) / 后复权(hfq) / 不复权(none)
- 分钟/小时 K 线（股票/ETF/LOF）：ak.stock_zh_a_hist_min_em / ak.fund_etf_hist_min_em / ak.fund_lof_hist_min_em
  - 1/5/15/30/60 分钟底层 + pandas.resample 合成 2h/4h 等更大周期
- 开放式公募基金净值（openfund）：ak.fund_open_fund_info_em（“单位净值走势”等）
  - 将“单位净值”规范为 close，复用 MA/MACD/RSI 指标

【输入增强】
- 友好日期解析：YYYYMMDD / YYYY-MM-DD / today / yesterday / 7d/-7d / ytd
- 强化鲁棒：自动重试 + 指数退避；中英文列名统一（含“股票代码/代码”→symbol）
- 指标：MA/MACD/RSI；导出 CSV/Excel/Parquet；信息(info)/统计(describe)/取样(head/tail)
- 可选绘图（close + MA）

【新参数】
- --asset stock/etf/lof/openfund：标的类型（默认 stock）
- --indicator：仅 openfund 生效，对应 ak.fund_open_fund_info_em 的 indicator
  （默认“单位净值走势”；可选：累计净值走势 / 累计收益率走势 / 同类排名百分比 等）

【分钟/小时能力】
- --tf 1m/5m/15m/30m/60m/2h/4h：最近的分时/小时K线（openfund 不支持）
- --id-days N：向前抓取 N 天 1分钟数据，再合成小时 K
- --include-partial：包含最后一根“未收完整”的 K（默认丢弃）
- --session cn：分钟数据仅取 A 股交易时段（09:30–11:30，13:00–15:00）再重采样
- --lookback ND：等价 start=today-Nd, end=today（与 --start/--end 互斥）

【正确调用示例（可直接复制）】
1) 股票（贵州茅台 600519）：最近 7 天，后复权，算 MA 和 MACD，导出 CSV
   python3 ak_a_hist_plus_fund.py --asset stock --symbol 600519 --start=-7d --adjust hfq \
     --ma 5 10 20 60 --indicators macd --export 600519_7d.csv

2) 股票（平安银行 000001）：今年以来，不复权，打印统计与前 10 行
   python3 ak_a_hist_plus_fund.py --asset stock --symbol 000001 --start ytd --adjust none --describe --head 10

3) 股票：周线 + RSI(14) + Parquet 导出
   python3 ak_a_hist_plus_fund.py --asset stock --symbol 600519 --period weekly --indicators rsi --rsi-window 14 \
     --export 600519_weekly.parquet

4) 股票：最近两天 **2 小时 K**（默认不含未收完整的最后一根）
   python3 ak_a_hist_plus_fund.py --asset stock --symbol 600519 --tf 2h --id-days 2 --adjust none --head 10

5) 股票：**4 小时 K**，并**包含**未收完整最后一根，导出 CSV
   python3 ak_a_hist_plus_fund.py --asset stock --symbol 600519 --tf 4h --id-days 2 --include-partial --export 600519_4h.csv

6) ETF（沪深300ETF 510300）：日频 + 前复权 + MA/MACD
   python3 ak_a_hist_plus_fund.py --asset etf --symbol 510300 --start=-180d --adjust qfq \
     --ma 5 10 20 --indicators macd --describe

7) ETF（上证50ETF 510050）：**60 分钟 K** 直接获取（再 describe）
   python3 ak_a_hist_plus_fund.py --asset etf --symbol 510050 --tf 60m --id-days 3 --adjust none --describe

8) ETF：最近 3 天 **2 小时 K** + 交易时段裁剪
   python3 ak_a_hist_plus_fund.py --asset etf --symbol 510300 --tf 2h --id-days 3 --session cn --head 20

9) LOF（示例 160216）：**60 分钟 K** + describe
   python3 ak_a_hist_plus_fund.py --asset lof --symbol 160216 --tf 60m --id-days 5 --adjust none --describe

10) 开放式基金（示例 161725）：单位净值日频 + MA/RSI + 导出 CSV
    python3 ak_a_hist_plus_fund.py --asset openfund --symbol 161725 --start=2023-01-01 --end=today \
      --ma 5 10 20 --indicators rsi --rsi-window 14 --export 161725_nav.csv

11) 开放式基金：累计净值口径（indicator 切换）
    python3 ak_a_hist_plus_fund.py --asset openfund --symbol 161725 --indicator 累计净值走势 --lookback 365d --head 15

【小贴士】
- 任何以 “-” 开头的参数值（如 -7d）都建议写成等号形式：--start=-7d。
- 若终端复制粘贴异常，注意智能引号/长横线（–），请使用 ASCII 字符。
- ETF/LOF 的分钟/日频字段与股票接近；开放式基金只有净值序列（无 OHLCV）。

"""

from __future__ import annotations
import sys
import time
import argparse
import re
from typing import List, Optional, Tuple
from datetime import datetime, timedelta

import numpy as np
import pandas as pd
import akshare as ak
from pathlib import Path

__VERSION__ = "1.4.0"

# -----------------------------
# 日期解析与校验
# -----------------------------
def _to_yyyymmdd(dt: datetime) -> str:
    return dt.strftime("%Y%m%d")

def _parse_date_like(s: Optional[str]) -> Optional[str]:
    """
    将用户输入的日期字符串规范为 YYYYMMDD。
    支持: YYYYMMDD / YYYY-MM-DD / today / yesterday / ytd / "7d" 或 "-7d"
    返回: YYYYMMDD 或 None（若 s is None）
    """
    if s is None:
        return None
    s = s.strip().lower()
    today = datetime.today()

    if s in ("today",):
        return _to_yyyymmdd(today)
    if s in ("yesterday", "yday"):
        return _to_yyyymmdd(today - timedelta(days=1))
    if s == "ytd":
        start = datetime(today.year, 1, 1)
        return _to_yyyymmdd(start)

    m = re.fullmatch(r'(-?\d+)d', s)
    if m:
        n = abs(int(m.group(1)))  # 统一按正数处理
        if n == 0:
            return _to_yyyymmdd(today)
        return _to_yyyymmdd(today - timedelta(days=n))

    for fmt in ("%Y%m%d", "%Y-%m-%d"):
        try:
            dt = datetime.strptime(s, fmt)
            return _to_yyyymmdd(dt)
        except Exception:
            continue

    raise ValueError(f"无法解析日期: {s}，请使用 YYYYMMDD / YYYY-MM-DD / today / yesterday / 7d/-7d / ytd")


def _resolve_date_range(start: Optional[str], end: Optional[str]) -> Tuple[str, str]:
    start_norm = _parse_date_like(start) if start else _parse_date_like("ytd")
    end_norm = _parse_date_like(end) if end else _parse_date_like("today")
    if start_norm > end_norm:
        raise ValueError(f"开始日期({start_norm})晚于结束日期({end_norm})，请检查。")
    return start_norm, end_norm


# -----------------------------
# 技术指标
# -----------------------------
def add_ma(df: pd.DataFrame, windows: List[int]) -> pd.DataFrame:
    for w in windows:
        if w > 0:
            df[f"ma{w}"] = df["close"].rolling(w, min_periods=1).mean()
    return df

def add_macd(df: pd.DataFrame,
             fast: int = 12,
             slow: int = 26,
             signal: int = 9) -> pd.DataFrame:
    ema_fast = df["close"].ewm(span=fast, adjust=False).mean()
    ema_slow = df["close"].ewm(span=slow, adjust=False).mean()
    dif = ema_fast - ema_slow
    dea = dif.ewm(span=signal, adjust=False).mean()
    macd = (dif - dea) * 2
    df["macd_dif"] = dif
    df["macd_dea"] = dea
    df["macd"] = macd
    return df

def add_rsi(df: pd.DataFrame, window: int = 14) -> pd.DataFrame:
    delta = df["close"].diff()
    gain = delta.clip(lower=0).rolling(window, min_periods=window).mean()
    loss = (-delta.clip(upper=0)).rolling(window, min_periods=window).mean()
    rs = gain / loss.replace(0, np.nan)
    df[f"rsi{window}"] = 100 - (100 / (1 + rs))
    return df


# -----------------------------
# 列名映射（中→英）
# -----------------------------
COL_RENAME = {
    '股票代码': 'symbol',
    '代码': 'symbol',
    '开盘': 'open',
    '收盘': 'close',
    '最高': 'high',
    '最低': 'low',
    '成交量': 'volume',
    '成交额': 'amount',
    '振幅': 'amplitude',
    '涨跌幅': 'pct_chg',
    '涨跌额': 'change',
    '换手率': 'turnover',
    '单位净值': 'close',  # openfund 对齐
}


# -----------------------------
# 工具函数
# -----------------------------
def _is_six_digit_code(symbol: str) -> bool:
    return isinstance(symbol, str) and symbol.isdigit() and len(symbol) == 6


# -----------------------------
# A股：日/周/月 K 线
# -----------------------------
def get_a_stock_data(symbol: str,
                     start_date: Optional[str] = None,
                     end_date: Optional[str] = None,
                     *,
                     period: str = "daily",
                     adjust: str = "hfq",
                     retries: int = 3,
                     backoff: float = 1.6,
                     verbose: bool = True) -> pd.DataFrame:
    if not _is_six_digit_code(symbol):
        raise ValueError("股票代码必须为 6 位数字（如 '600519'）")

    period = period.lower().strip()
    if period not in {"daily", "weekly", "monthly"}:
        raise ValueError("period 仅支持: daily / weekly / monthly")

    adjust = (adjust or "none").lower().strip()
    if adjust not in {"hfq", "qfq", "none"}:
        raise ValueError("adjust 仅支持: hfq / qfq / none")

    start_norm, end_norm = _resolve_date_range(start_date, end_date)

    if verbose:
        print(f"[INFO] 请求 STOCK {symbol} {period} {adjust} {start_norm}~{end_norm}")

    _adj = "" if adjust == "none" else adjust
    last_err = None

    for attempt in range(1, retries + 1):
        try:
            df = ak.stock_zh_a_hist(
                symbol=symbol,
                period=period,
                start_date=start_norm,
                end_date=end_norm,
                adjust=_adj
            )
            if df is None or df.empty:
                raise RuntimeError("AKShare 返回空数据，请检查代码/日期范围/接口可用性。")

            date_col = "日期" if "日期" in df.columns else None
            if not date_col:
                raise KeyError("返回结果缺少 '日期' 列，可能接口变化。")
            df[date_col] = pd.to_datetime(df[date_col])
            df = df.set_index(date_col).sort_index()

            rename_map = {k: v for k, v in COL_RENAME.items() if k in df.columns}
            df = df.rename(columns=rename_map)

            if "close" in df.columns:
                df["ret"] = df["close"].pct_change()
                df["log_ret"] = np.log(df["close"]).diff()

            if verbose:
                print(f"[INFO] 成功获取 {len(df)} 条记录。")
            return df

        except Exception as e:
            last_err = e
            wait = (backoff ** (attempt - 1))
            if attempt < retries:
                if verbose:
                    print(f"[WARN] 第 {attempt}/{retries-1} 次尝试失败：{e}，{wait:.1f}s 后重试...")
                time.sleep(wait)
            else:
                if verbose:
                    print(f"[ERROR] 数据获取失败：{e}")
                raise

    if last_err:
        raise last_err


#港股函数
def get_hk_stock_data(symbol: str,
                      start_date: Optional[str] = None,
                      end_date: Optional[str] = None,
                      *,
                      period: str = "daily",
                      adjust: str = "",
                      retries: int = 3,
                      backoff: float = 1.6,
                      verbose: bool = True) -> pd.DataFrame:
    """
    获取港股日/周/月行情
    使用 ak.stock_hk_hist
    """
    # 处理 symbol 格式：允许 HK1810 或 01810
    if symbol.upper().startswith("HK"):
        symbol = symbol[2:]
    symbol = symbol.zfill(5)  # 港股一般是 5 位数字，比如 01810

    start_norm, end_norm = _resolve_date_range(start_date, end_date)

    if verbose:
        print(f"[INFO] 请求 HKStock {symbol} {period} {start_norm}~{end_norm}")

    last_err = None
    for attempt in range(1, retries + 1):
        try:
            df = ak.stock_hk_hist(
                symbol=symbol,
                period=period,
                start_date=start_norm,
                end_date=end_norm,
                adjust=adjust
            )
            if df is None or df.empty:
                raise RuntimeError("AKShare 港股返回空数据，请检查代码/日期范围/接口可用性。")

            date_col = "日期" if "日期" in df.columns else None
            if not date_col:
                raise KeyError("返回结果缺少 '日期' 列，可能接口变化。")
            df[date_col] = pd.to_datetime(df[date_col])
            df = df.set_index(date_col).sort_index()

            rename_map = {k: v for k, v in COL_RENAME.items() if k in df.columns}
            df = df.rename(columns=rename_map)

            if "close" in df.columns:
                df["ret"] = df["close"].pct_change()
                df["log_ret"] = np.log(df["close"]).diff()

            if verbose:
                print(f"[INFO] 成功获取 {len(df)} 条记录。")
            return df

        except Exception as e:
            last_err = e
            wait = (backoff ** (attempt - 1))
            if attempt < retries:
                if verbose:
                    print(f"[WARN] 第 {attempt}/{retries-1} 次尝试失败：{e}，{wait:.1f}s 后重试...")
                time.sleep(wait)
            else:
                if verbose:
                    print(f"[ERROR] 港股数据获取失败：{e}")
                raise

    if last_err:
        raise last_err






# -----------------------------
# ETF / LOF：日/周/月 K 线
# -----------------------------
def get_etf_or_lof_data(symbol: str,
                        start_date: Optional[str],
                        end_date: Optional[str],
                        *,
                        period: str = "daily",
                        adjust: str = "none",
                        is_etf: bool = True,
                        retries: int = 3,
                        backoff: float = 1.6,
                        verbose: bool = True) -> pd.DataFrame:
    if not _is_six_digit_code(symbol):
        raise ValueError("基金代码必须为 6 位数字（如 '510300'、'160216'）")

    period = period.lower().strip()
    if period not in {"daily", "weekly", "monthly"}:
        raise ValueError("period 仅支持: daily / weekly / monthly")

    adjust = (adjust or "none").lower().strip()
    if adjust not in {"hfq", "qfq", "none"}:
        raise ValueError("adjust 仅支持: hfq / qfq / none")

    start_norm, end_norm = _resolve_date_range(start_date, end_date)
    typ = "ETF" if is_etf else "LOF"

    if verbose:
        print(f"[INFO] 请求 {typ} {symbol} {period} {adjust} {start_norm}~{end_norm}")

    _adj = "" if adjust == "none" else adjust
    api = ak.fund_etf_hist_em if is_etf else ak.fund_lof_hist_em

    last_err = None
    for attempt in range(1, retries + 1):
        try:
            df = api(symbol=symbol, period=period, start_date=start_norm, end_date=end_norm, adjust=_adj)
            if df is None or df.empty:
                raise RuntimeError("基金日频数据为空，请检查代码/日期/接口可用性。")

            date_col = "日期" if "日期" in df.columns else None
            if not date_col:
                date_col = "净值日期" if "净值日期" in df.columns else None
            if not date_col:
                raise KeyError("返回结果缺少日期列（'日期'/'净值日期'），可能接口变化。")

            df[date_col] = pd.to_datetime(df[date_col])
            df = df.set_index(date_col).sort_index()

            rename_map = {k: v for k, v in COL_RENAME.items() if k in df.columns}
            df = df.rename(columns=rename_map)

            if "close" in df.columns:
                df["ret"] = df["close"].pct_change()
                df["log_ret"] = np.log(df["close"]).diff()

            if verbose:
                print(f"[INFO] 成功获取 {len(df)} 条记录。")
            return df

        except Exception as e:
            last_err = e
            wait = (backoff ** (attempt - 1))
            if attempt < retries:
                if verbose:
                    print(f"[WARN] 第 {attempt}/{retries-1} 次尝试失败：{e}，{wait:.1f}s 后重试...")
                time.sleep(wait)
            else:
                if verbose:
                    print(f"[ERROR] 数据获取失败：{e}")
                raise

    if last_err:
        raise last_err


# -----------------------------
# 开放式公募基金净值（日频）
# -----------------------------
def get_openfund_nav(symbol: str,
                     start_date: Optional[str],
                     end_date: Optional[str],
                     *,
                     indicator: str = "单位净值走势",
                     retries: int = 3,
                     backoff: float = 1.6,
                     verbose: bool = True) -> pd.DataFrame:
    if not _is_six_digit_code(symbol):
        raise ValueError("基金代码必须为 6 位数字（如 '161725'）")

    start_norm, end_norm = _resolve_date_range(start_date, end_date)
    if verbose:
        print(f"[INFO] 请求 OpenFund {symbol} {indicator} {start_norm}~{end_norm}")

    last_err = None
    for attempt in range(1, retries + 1):
        try:
            df = ak.fund_open_fund_info_em(symbol=symbol, indicator=indicator)
            if df is None or df.empty:
                raise RuntimeError("开放式基金净值数据为空，请检查代码/接口可用性。")

            date_col = "净值日期" if "净值日期" in df.columns else ("日期" if "日期" in df.columns else None)
            if not date_col:
                raise KeyError("返回结果缺少日期列，可能接口变化。")

            df[date_col] = pd.to_datetime(df[date_col])
            df = df.set_index(date_col).sort_index()

            rename_map = {k: v for k, v in COL_RENAME.items() if k in df.columns}
            df = df.rename(columns=rename_map)

            if "close" not in df.columns:
                raise ValueError("未找到单位净值/净值列，请将 --indicator 设置为‘单位净值走势’或检查接口返回列。")

            # 按日期裁剪
            df = df.loc[(df.index >= pd.to_datetime(start_norm)) & (df.index <= pd.to_datetime(end_norm))]

            df["ret"] = df["close"].pct_change()
            df["log_ret"] = np.log(df["close"]).diff()

            if verbose:
                print(f"[INFO] 成功获取 {len(df)} 条记录。")
            return df

        except Exception as e:
            last_err = e
            wait = (backoff ** (attempt - 1))
            if attempt < retries:
                if verbose:
                    print(f"[WARN] 第 {attempt}/{retries-1} 次尝试失败：{e}，{wait:.1f}s 后重试...")
                time.sleep(wait)
            else:
                if verbose:
                    print(f"[ERROR] 数据获取失败：{e}")
                raise

    if last_err:
        raise last_err


# -----------------------------
# 分钟级 K 线 + 合成小时 K（股票/ETF/LOF）
# -----------------------------
_TF_ALIASES = {
    "1m": 1, "5m": 5, "15m": 15, "30m": 30, "60m": 60,
    "2h": 120, "4h": 240
}

def _parse_tf_to_minutes(tf: str) -> int:
    tf = (tf or "").strip().lower()
    if tf in _TF_ALIASES:
        return _TF_ALIASES[tf]
    m = re.fullmatch(r"(\d+)\s*(m|min|h|hour|hours)", tf)
    if m:
        n = int(m.group(1))
        unit = m.group(2)
        return n if unit.startswith("m") else n * 60
    raise ValueError("不支持的 --tf 周期；示例：1m/5m/15m/30m/60m/2h/4h")


def _now_floor_minute() -> datetime:
    now = datetime.now()
    return now.replace(second=0, microsecond=0)


def _clip_session_cn(df: pd.DataFrame) -> pd.DataFrame:
    """
    仅保留 A 股常规交易时间的分钟数据：
    09:30–11:30、13:00–15:00（不处理节假日/盘前盘后）
    """
    if df.empty:
        return df
    idx = df.index
    hhmm = idx.strftime("%H%M").astype(int)
    mask = (
        ((hhmm >= 930) & (hhmm <= 1130)) |
        ((hhmm >= 1300) & (hhmm <= 1500))
    )
    return df[mask]


def get_minute_em(asset: str,
                  symbol: str,
                  start_dt: datetime,
                  end_dt: datetime,
                  *,
                  period_min: int = 1,
                  adjust: str = "none",
                  verbose: bool = True) -> pd.DataFrame:
    """
    分钟级行情（东财）
    stock -> ak.stock_zh_a_hist_min_em
    etf   -> ak.fund_etf_hist_min_em
    lof   -> ak.fund_lof_hist_min_em
    openfund -> 不支持分钟
    """
    if asset not in {"stock", "etf", "lof"}:
        raise ValueError("开放式公募基金(openfund)不支持分钟/小时K线")

    if not _is_six_digit_code(symbol):
        raise ValueError("代码必须为 6 位数字")

    if period_min not in {1, 5, 15, 30, 60}:
        raise ValueError("period_min 仅支持 1/5/15/30/60")

    _adj = "" if (adjust or "none").lower() == "none" else adjust.lower()
    s = start_dt.strftime("%Y-%m-%d %H:%M:%S")
    e = end_dt.strftime("%Y-%m-%d %H:%M:%S")

    if verbose:
        print(f"[INFO] 分钟数据: {asset} {symbol} {period_min}m {s} ~ {e} adjust={_adj or 'none'}")

    if asset == "stock":
        df = ak.stock_zh_a_hist_min_em(symbol=symbol, start_date=s, end_date=e, period=str(period_min), adjust=_adj)
    elif asset == "etf":
        df = ak.fund_etf_hist_min_em(symbol=symbol, start_date=s, end_date=e, period=str(period_min), adjust=_adj)
    else:  # lof
        df = ak.fund_lof_hist_min_em(symbol=symbol, start_date=s, end_date=e, period=str(period_min), adjust=_adj)

    if df is None or df.empty:
        raise RuntimeError("分钟数据为空，请检查参数或时间范围")

    if "时间" not in df.columns:
        raise KeyError("分钟数据缺少 '时间' 列，可能接口变化")

    df["时间"] = pd.to_datetime(df["时间"])
    df = df.set_index("时间").sort_index()

    rename_map = {k: v for k, v in COL_RENAME.items() if k in df.columns}
    df = df.rename(columns=rename_map)
    return df


def resample_to_tf(df_minute: pd.DataFrame,
                   tf_minutes: int,
                   *,
                   include_partial: bool = False,
                   align_to_hour: bool = True) -> pd.DataFrame:
    """
    用分钟数据合成更大周期 K 线（如 120/240 分钟）
    - align_to_hour=True -> 以自然整点对齐（会生成如 10-12、12-14 的区间）
    - include_partial=False -> 丢弃最后一个未收完整的K
    """
    if df_minute.empty:
        return df_minute
    if "close" not in df_minute.columns:
        raise ValueError("分钟数据缺少行情列")

    origin = "start_day"
    offset = "0min" if align_to_hour else "9h30min"

    agg = {
        "open": "first",
        "high": "max",
        "low": "min",
        "close": "last",
        "volume": "sum",
        "amount": "sum",
    }
    out = (
        df_minute
        .resample(f"{tf_minutes}min", origin=origin, offset=offset,
                  label="right", closed="right")
        .agg(agg)
    )

    out = out.dropna(how="all", subset=["open", "high", "low", "close"])

    if not include_partial:
        # 丢弃最后一个未收完整的K（以“当前时刻向下取整到tf边界”为准）
        now_end = _now_floor_minute()
        last_end_allowed = pd.Timestamp(now_end).floor(f"{tf_minutes}min")
        out = out[out.index <= last_end_allowed]

    return out


# -----------------------------
# 导出
# -----------------------------
def export_dataframe(df: pd.DataFrame, path: str, verbose: bool = True) -> None:
    path = path.strip()
    if path.lower().endswith(".csv"):
        df.to_csv(path, encoding="utf-8-sig")
    elif path.lower().endswith(".xlsx"):
        df.to_excel(path, engine="openpyxl")
    elif path.lower().endswith(".parquet"):
        df.to_parquet(path, index=True)
    else:
        raise ValueError("仅支持导出为 .csv / .xlsx / .parquet")
    if verbose:
        print(f"[INFO] 已导出: {path}")


# -----------------------------
# 可选绘图（需要 matplotlib）
# -----------------------------
def plot_price_with_ma(df: pd.DataFrame, mas: List[int]) -> None:
    try:
        import matplotlib.pyplot as plt
    except Exception as e:
        print(f"[WARN] 未安装 matplotlib，无法绘图：{e}")
        return
    ax = df["close"].plot(figsize=(12, 5), linewidth=1.3, label="close")
    for w in mas:
        col = f"ma{w}"
        if col in df.columns:
            df[col].plot(ax=ax, linewidth=1.0, label=col)
    ax.set_title("Close & MAs")
    ax.grid(True, linestyle="--", alpha=0.3)
    ax.legend()
    plt.tight_layout()
    plt.show()


# -----------------------------
# 命令行入口
# -----------------------------
def build_argparser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="ak_a_hist_plus_fund.py",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        description=f"下载并分析 A股/基金 历史行情（AKShare）。版本 {__VERSION__}"
    )
    p.add_argument("--asset", default="stock",
                   choices=["stock", "etf", "lof", "openfund"], help="标的类型")
    p.add_argument("--symbol", required=True, help="6位代码，如 A股600519 / ETF510300 / LOF160216 / 基金161725")

    # 日/周/月
    p.add_argument("--start", default="ytd",
                   help="开始日期：YYYYMMDD/ YYYY-MM-DD / today / yesterday / 7d/-7d / ytd")
    p.add_argument("--end", default="today",
                   help="结束日期：同上")
    p.add_argument("--period", default="daily",
                   choices=["daily", "weekly", "monthly"], help="周期（openfund 忽略，固定日频净值）")
    p.add_argument("--adjust", default="hfq",
                   choices=["hfq", "qfq", "none"], help="复权方式（openfund 忽略）")
    p.add_argument("--retries", type=int, default=3, help="失败自动重试次数")
    p.add_argument("--backoff", type=float, default=1.6, help="指数退避基数")
    p.add_argument("--verbose", action="store_true", help="打印更详细的过程信息")

    # openfund 指标曲线
    p.add_argument("--indicator", default="单位净值走势",
                   help="仅 openfund 生效：单位净值走势/累计净值走势/累计收益率走势/同类排名百分比 等")

    # 相对窗口（避免 -7d 歧义）
    p.add_argument("--lookback", default="",
                   help="相对回溯区间（7d/30d）。等价于 start=today-<n>d, end=today；与 --start/--end 互斥")

    # Intraday / Hourly（openfund 不支持）
    p.add_argument("--tf", default="",
                   help="分时/小时K线：1m/5m/15m/30m/60m/2h/4h；openfund 不支持；留空则走日/周/月/净值")
    p.add_argument("--id-days", type=int, default=2,
                   help="当使用 --tf 时，向前回看N天分钟数据（用于合成更大周期）")
    p.add_argument("--include-partial", action="store_true",
                   help="包含最后一个未收完整的K线（默认不包含）")
    p.add_argument("--session", default="",
                   choices=["", "cn"],
                   help="分钟数据的交易时段裁剪；'cn' = 09:30–11:30, 13:00–15:00")

    # 指标与统计
    p.add_argument("--ma", type=int, nargs="*", default=[],
                   help="计算均线窗口（可多选），如 --ma 5 10 20 60")
    p.add_argument("--indicators", nargs="*", default=[], choices=["macd", "rsi"],
                   help="技术指标选项")
    p.add_argument("--rsi-window", type=int, default=14, help="RSI 窗口")
    p.add_argument("--describe", action="store_true", help="打印基础统计")
    p.add_argument("--info", action="store_true", help="打印 DataFrame.info()")
    p.add_argument("--head", type=int, default=0, help="打印前 N 行（0 表示不打印）")
    p.add_argument("--tail", type=int, default=0, help="打印后 N 行（0 表示不打印）")

    # 导出与绘图
    p.add_argument("--export", default="", help="导出路径（.csv / .xlsx / .parquet），留空不导出")
    p.add_argument("--plot", action="store_true", help="绘制收盘价与均线（需要 matplotlib）")

    return p


def main(argv: Optional[List[str]] = None) -> int:
    args = build_argparser().parse_args(argv)

    # --lookback 互斥处理
    if getattr(args, "lookback", ""):
        if args.start != "ytd" or args.end != "today":
            print("[ERROR] --lookback 与 --start/--end 互斥；请二选一。", file=sys.stderr)
            return 1
        lb = args.lookback.strip().lower()
        m = re.fullmatch(r'(\d+)d', lb)
        if not m:
            print("[ERROR] --lookback 仅支持如 '7d', '30d' 的写法。", file=sys.stderr)
            return 1
        n = int(m.group(1))
        if n <= 0:
            print("[ERROR] --lookback 需要正整数天数，如 7d。", file=sys.stderr)
            return 1
        args.start = f"{n}d"  # '7d' 与 '-7d' 在解析中等价
        args.end = "today"

    try:
        # --- 如果指定了 --tf（仅 stock/etf/lof 支持） ---
        if args.tf:
            if args.asset == "openfund":
                raise ValueError("openfund 不支持 --tf（分钟/小时K线），请使用日频净值")

            tf_minutes = _parse_tf_to_minutes(args.tf)
            end_dt = _now_floor_minute()
            start_dt = end_dt - timedelta(days=max(1, args.id_days) + 1)  # 多给1天缓冲
            base_min = 1  # 以 1 分钟底层最稳

            df_min = get_minute_em(
                asset=args.asset,
                symbol=args.symbol,
                start_dt=start_dt,
                end_dt=end_dt,
                period_min=base_min,
                adjust=args.adjust,
                verbose=args.verbose
            )

            if args.session == "cn":
                df_min = _clip_session_cn(df_min)

            df = resample_to_tf(
                df_minute=df_min,
                tf_minutes=tf_minutes,
                include_partial=args.include_partial,
                align_to_hour=True
            )

        else:
            # --- 否则走日/周/月/净值 ---
            if args.asset == "stock":
                # 自动识别：A股 / 港股
                if _is_six_digit_code(args.symbol):
                    df = get_a_stock_data(
                        symbol=args.symbol,
                        start_date=args.start,
                        end_date=args.end,
                        period=args.period,
                        adjust=args.adjust,
                        retries=args.retries,
                        backoff=args.backoff,
                        verbose=args.verbose
                    )
                else:
                    df = get_hk_stock_data(
                        symbol=args.symbol,
                        start_date=args.start,
                        end_date=args.end,
                        period=args.period,
                        adjust="",  # 港股 adjust 参数一般空字符串
                        retries=args.retries,
                        backoff=args.backoff,
                        verbose=args.verbose
                    )

            elif args.asset in {"etf", "lof"}:
                df = get_etf_or_lof_data(
                    symbol=args.symbol,
                    start_date=args.start,
                    end_date=args.end,
                    period=args.period,
                    adjust=args.adjust,
                    is_etf=(args.asset == "etf"),
                    retries=args.retries,
                    backoff=args.backoff,
                    verbose=args.verbose
                )
            else:  # openfund
                df = get_openfund_nav(
                    symbol=args.symbol,
                    start_date=args.start,
                    end_date=args.end,
                    indicator=args.indicator,
                    retries=args.retries,
                    backoff=args.backoff,
                    verbose=args.verbose
                )

        # 指标
        if args.ma:
            df = add_ma(df, args.ma)
        if "macd" in args.indicators:
            df = add_macd(df)
        if "rsi" in args.indicators:
            df = add_rsi(df, window=args.rsi_window)

        # 输出信息
        if args.info:
            print("\n[INFO] DataFrame.info():")
            print(df.info())

        if args.head > 0:
            print(f"\n[DATA] 前 {args.head} 行：")
            print(df.head(args.head))

        if args.tail > 0:
            print(f"\n[DATA] 后 {args.tail} 行：")
            print(df.tail(args.tail))

        if args.describe:
            print("\n[STAT] 基础统计：")
            print(df.describe(include="all"))

        # 导出
        if args.export:
            export_dataframe(df, args.export, verbose=True)

        # 绘图
        if args.plot:
            mas = args.ma if args.ma else []
            plot_price_with_ma(df, mas)

        # 默认预览
        if not any([args.head, args.tail, args.describe, args.info]):
            print("\n[DATA] 结果预览（前5行）：")
            print(df.head())

        return 0

    except Exception as e:
        print("\n[ERROR] 发生错误：", str(e))
        print("\n[建议排查清单]")
        print("1) 检查代码是否为 6 位数字（无需加市场后缀）。")
        print("2) 若用 --tf：尝试增大 --id-days 或改用 --session cn；如仍空数据，缩短时间窗口重试。")
        print("3) 检查日期格式：YYYYMMDD / YYYY-MM-DD / today / yesterday / 7d/-7d / ytd；或改用 --lookback。")
        print("4) 尝试降低请求频率或升级 AKShare：pip install -U akshare。")
        print("5) AKShare 字段若有变动，留意本脚本的列名映射 COL_RENAME。")
        return 1


if __name__ == "__main__":
    sys.exit(main())

